Skip to content

sql(postgres): only enqueue queries dispatched from inside a Bind encoder - #38231

Closed
robobun wants to merge 4 commits into
mainfrom
farm/09aae61c/postgres-reentrant-advance
Closed

robobun wants to merge 4 commits into
mainfrom
farm/09aae61c/postgres-reentrant-advance

Conversation

@robobun

@robobun robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A parameter whose valueOf() / toString() / toJSON() dispatches another query on the same connection (any max: 1 pool, reserved connection or transaction) crashes the process on the first execution of the statement:
    panic: range start index 42 out of range for slice of length 4
    
    in Writer::pwrite (src/sql_jsc/postgres/PostgresSQLConnection.rs); debug builds trip pending_requests underflow first. valueOf() is also observably called twice.
  • Cause: advance() calls PostgresRequest::bind_and_execute for the head request while it is still Pending, and write_bind calls into user JS per parameter. The nested execute() reaches PostgresSQLQuery::do_run synchronously (src/js/bun/sql.ts onQueryConnected -> handle.run), which enqueues and calls advance_and_flush(): the nested advance() binds the head request a second time (two Bind+Execute+Sync for one request) and flush_data() then empties write_buffer. When the outer write_bind resumes, its LengthWriter patches an offset into the buffer that was just flushed.
  • For a statement that is already prepared, do_run writes the Bind itself (PostgresSQLQuery.rs, StatementStatus::Prepared fast path) before the request is on the queue. A nested dispatch sees an idle connection, writes its own frames into the middle of the half-encoded Bind and enqueues itself first, so the server's error for the torn message is delivered to the nested query and the outer one never settles. A nested query reusing a prepared statement takes the same fast path from inside advance() too (pending_requests was already decremented for the request being encoded), corrupting the wire the same way.

Fix

  • Adds ConnectionFlags::IS_DISPATCHING, held by advance() for the whole drain and by do_run's enqueue-time Bind for the duration of bind_and_execute (PostgresSQLConnection::while_dispatching). While it is set, do_run only enqueues (all three of its write gates), advance() returns immediately and flush_data() is a no-op.
  • do_run's fast path now enqueues the request (as Binding, with its RequestCounter still None, so a cleanup during the encode is a no-op for it) before encoding it. If the encode fails, discard_failed_request disposes of it exactly as advance() disposes of its own failures (dropped at the head, otherwise left Fail for the sweep), dispatches anything user JS queued behind it, and the original exception is rethrown. The statement ref it holds is released when the query object is dropped, as for every other enqueued request; release_query_ref() remains the cleanup for requests that never reached the queue.
  • Ordinary (non-re-entrant) flows produce the same bytes, end state and errors as before: the only differences are the order of two bookkeeping steps inside do_run that nothing observes, and a few flag reads/writes per query.
  • Why this is correct: replies are attributed to requests in queue order, so a request's bytes have to reach the wire in the same relative order as its queue entry. Keeping the request being encoded on the queue while its parameters are converted, and letting nothing else write, drain or flush until that conversion is done, makes the bytes of each request contiguous and in queue order. Nothing is lost by deferring the nested request: every holder of the flag drains and flushes when it finishes (the advance() loop re-reads the queue length each iteration and reaches the nested request in the same pass, or the ReadyForQuery of the request it just wrote triggers the next advance(); ReadyForQuery / drain_internal / advance_and_flush / do_run all flush after the encoder returns). Flushes skipped inside the window only ever concern bytes the holder flushes itself.
  • The flag is also what makes sql(postgres): discard a partial Bind when a parameter fails to encode #34732's rollback-on-throw well defined: nothing else can append to the buffer between its snapshot and its truncate.
  • MySQL converts every parameter to native values before touching its writer, so it has no crash; the residual ordering gap there is handed off separately.
  • Verified with test/js/sql/postgres-dispatch-during-bind.test.ts (10 scenarios, each in a subprocess against the postgres_plain service: both encode sites, nested new / prepared / same statement, a burst including a simple query, a nested query that dispatches again from its own Bind, a transaction, prepare: false, and a conversion that dispatches then throws, both with the request at the head of the queue and behind an in-flight request). Without the fix 8 of 10 fail (the subprocess panics, or the wedged-connection shapes time out), on both the release binary and a debug build; the two throw scenarios are regression guards for the new failure path. With the fix all 10 pass, locally and on the ASAN CI lane against the real service.
  • test/js/sql/sql.test.ts (postgres block, 853 tests) against a local server on the debug build: no new failures; the four timing-based tests that failed also fail on an unmodified debug build.
  • test/js/sql/postgres-prepared-pipeline-reorder, postgres-split-prepare-reorder, postgres-simple-query-pipeline, postgres-finish-request-underflow, postgres-failed-connection-resurrection, postgres-error-then-datarow, postgres-frame-boundary, sql-prepare-false: pass.

Background

  • Extended query protocol: a prepared-statement execution is sent as Bind (parameter values) + Execute + Sync. The client keeps one FIFO of requests per connection and attributes every reply the server sends to the request at the head of that FIFO, so the order of requests on the wire must equal their order in the FIFO.
  • advance() is the drain loop that turns queued requests into bytes; it runs from the ReadyForQuery handler and from the socket's writable callback. do_run is the native side of query.execute(); for a statement that is already prepared it skips the queue walk and writes the Bind at enqueue time.
  • Length prefixes: a postgres frontend message starts with a 4-byte length that is only known once the body is written, so write_bind reserves the 4 bytes, remembers their offset into write_buffer, and patches them in place afterwards (LengthWriter). Flushing the buffer in between both sends a message with a zero length and invalidates the remembered offset.
  • Converting a parameter is a JS call (coerce -> valueOf, from_js -> toString, json_stringify_fast -> toJSON, plus any getters on the binding array), so arbitrary user code runs in the middle of encoding. The pool's dispatch path is synchronous, so that code can land back in do_run for the same connection before the encoder returns.

[review] gate passed · iteration 1 · 5 files touched

fails on main (without fix)
ASAN without fix: 8 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/sql/postgres-dispatch-during-bind.test.ts
bun test v1.4.0 (1326309ad)

test/js/sql/postgres-dispatch-during-bind.test.ts:
Container ready via docker-compose: postgres_plain at 127.0.0.1:5432
(fail) postgres > first execution, nested new statement [5037.00ms]
  ^ this test timed out after 5000ms.
(fail) postgres > first execution, nested prepared statement [5003.13ms]
  ^ this test timed out after 5000ms.
(fail) postgres > prepared statement, nested new statement [5011.05ms]
  ^ this test timed out after 5000ms.
(fail) postgres > prepared statement, nested same statement [5004.78ms]
  ^ this test timed out after 5000ms.
(fail) postgres > prepared statement, nested burst [5010.23ms]
  ^ this test timed out after 5000ms.

# Unhandled error between tests
-------------------------------
108 | 
109 |       let result: unknown = stdout;
110 |       try {
111 |         result = JSON.parse(stdout);
112 |       } catch {}
113 |       expect({ result, exitCode, stderr }).toEqual({
                                                 ^
error: 
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (ef872decd)

test/js/sql/postgres-dispatch-during-bind.test.ts:
Container ready via docker-compose: postgres_plain at 127.0.0.1:5432
(pass) postgres > first execution, nested new statement [23.51ms]
(pass) postgres > prepared statement, nested new statement [22.11ms]
(pass) postgres > prepared statement, nested same statement [27.34ms]
(pass) postgres > inside a transaction [28.28ms]
(pass) postgres > first execution, nested prepared statement [31.46ms]
(pass) postgres > prepared statement, nested burst [32.44ms]
(pass) postgres > prepared statement, conversion throws after dispatching [29.60ms]
(pass) postgres > nested query dispatches again from its own bind [39.39ms]
(pass) postgres > unnamed statements (prepare: false) [40.11ms]
(pass) postgres > prepared statement behind an in-flight query, conversion throws after dispatching [42.34ms]

 10 pass
 0 fail
 10 expect() calls
Ran 10 tests across 1 file. [233.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/sql/postgres-dispatch-during-bind.test.ts
bun test v1.4.0 (1326309ad)

test/js/sql/postgres-dispatch-during-bind.test.ts:
Container ready via docker-compose: postgres_plain at 127.0.0.1:5432
(pass) postgres > first execution, nested new statement [906.83ms]
(pass) postgres > first execution, nested prepared statement [929.16ms]
(pass) postgres > prepared statement, nested same statement [982.72ms]
(pass) postgres > prepared statement, nested new statement [1048.47ms]
(pass) postgres > prepared statement, nested burst [1251.83ms]
(pass) postgres > inside a transaction [987.08ms]
(pass) postgres > nested query dispatches again from its own bind [1139.50ms]
(pass) postgres > prepared statement, conversion throws after dispatching [985.82ms]
(pass) postgres > unnamed statements (prepare: false) [1109.38ms]
(pass) postgres > prepared statement behind an in-flight query, conversion throws after dispatching [860.49ms]

 10 pass
 0 fail
 10 expect() calls
Ran 10 tests across 1 file. [5.10s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 665ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/8] cxx obj/src/jsc/bindings/napi.cpp.o
[2/8] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[3/8] gen cpp.rs (cppbind)
[3/8] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0
... (truncated)
diff hotspot
src/sql/shared/ConnectionFlags.rs                  |   3 +
 src/sql_jsc/postgres/PostgresSQLConnection.rs      |  58 +++++-
 src/sql_jsc/postgres/PostgresSQLQuery.rs           |  68 ++++---
 .../sql/postgres-dispatch-during-bind-fixture.ts   | 195 +++++++++++++++++++++
 test/js/sql/postgres-dispatch-during-bind.test.ts  | 121 +++++++++++++
 5 files changed, 424 insertions(+), 21 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                                  reads  edits  tests
src/sql/shared/ConnectionFlags.rs                         2      2      0
src/sql_jsc/postgres/PostgresSQLConnection.rs            18     13      0
src/sql_jsc/postgres/PostgresSQLQuery.rs                 10     12      0
test/js/sql/postgres-dispatch-during-bind-fixture.ts      6     12      0
test/js/sql/postgres-dispatch-during-bind.test.ts         5     10      0

@coderabbitai

coderabbitai Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 53171279-332d-409f-a23a-6c3479b05698

📥 Commits

Reviewing files that changed from the base of the PR and between 54f0271 and 1326309.

📒 Files selected for processing (5)
  • src/sql/shared/ConnectionFlags.rs
  • src/sql_jsc/postgres/PostgresSQLConnection.rs
  • src/sql_jsc/postgres/PostgresSQLQuery.rs
  • test/js/sql/postgres-dispatch-during-bind-fixture.ts
  • test/js/sql/postgres-dispatch-during-bind.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:05 AM PT - Aug 14th, 2026

❌ @robobun, your commit 1326309 has some failures in Build #95374 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38231

That installs a local version of the PR into your bun-38231 executable, so you can run:

bun-38231 --bun

@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on the released binary (1.4.0 canary) against a local postgres with a max: 1 pool and a parameter whose valueOf() executes a second tagged query on the same sql instance: the first execution panics in Writer::pwrite (range start index 42 out of range for slice of length 4, valueOf called twice); with the statement already prepared, the nested query rejects with the outer query's server error and the outer one never settles; same inside sql.begin() and with prepare: false. All of these are scenarios in test/js/sql/postgres-dispatch-during-bind.test.ts (8 of 10 fail without the fix, 10 of 10 pass with it). The branch is rebased over the RequestCounter change from #38000.

CI on the current head (build 95374): every job that ran passed, including the new test against the real postgres service on the Linux lanes; the build is marked failed only because the two darwin 14 aarch64 - test-bun jobs expired waiting for an agent, and the handful of per-test retries were in unrelated files (napi, proxy-stress-errors, inspect-error-leak). Nothing in this change is platform-specific. Ready for review.

Related but separate findings from reviewing this: closing the reserved connection from inside a conversion hits the same panic through a different trigger, and close({ timeout: 0 }) does not close immediately; both are tracked separately. The torn write buffer left behind by a throwing conversion is #34732.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it reworks re-entrancy in the Postgres connection dispatcher (queue-order vs. wire-order invariants, a new enqueue-before-encode failure path with its own refcount/status handling, and deferred draining/flushing), a human familiar with this state machine should also take a look.

What was reviewed:

  • IS_DISPATCHING gating on advance(), flush_data(), and all three write gates in do_run — every holder drains/flushes after clearing the flag, and scopeguard::defer! clears it on unwind.
  • Refcount balance on the new bind_and_execute failure path: the speculative ref_() becomes the queue's ref once enqueued, released by discard_request at head or by advance()'s Fail sweep otherwise; poll_ref/this_value upgrade are correctly not taken before the early return.
  • Test fixture: subprocess-per-scenario with a watchdog that outlives the test timeout, concurrent pipe drain, describeWithContainer, and exact-value assertions on both outer and nested query results plus conversion count.
Extended reasoning...

Overview

This PR fixes a process-level panic (and the adjacent wire-corruption / hang failure modes) that occurs when a Postgres query parameter's valueOf() / toString() / toJSON() synchronously dispatches another query on the same connection while the outer query's Bind message is still being encoded. It adds ConnectionFlags::IS_DISPATCHING, held by advance() for its whole drain and by do_run's enqueue-time Bind, so that a nested dispatch only enqueues and leaves draining/flushing to the encoder. do_run's prepared-statement fast path is reordered to enqueue (as Binding) before encoding, and a new discard_failed_request helper disposes of the request on encode failure the same way advance() does, then dispatches whatever the user JS enqueued behind it. Nine subprocess-based test scenarios cover both encode sites, nested new/prepared/same statements, a mixed burst, second-order nesting, transactions, prepare: false, and a conversion that dispatches then throws.

Security risks

None identified. The change is defensive against hostile user JS re-entering the dispatcher, but it doesn't touch auth, TLS, or input parsing.

Level of scrutiny

High. This is a re-entrancy fix in the Postgres wire-protocol state machine, where correctness depends on the queue-order == wire-order invariant, intrusive-refcount balance across a new early-return path, and the guarantee that every deferred drain/flush is picked up by whoever holds the flag. The PR description's correctness argument is detailed and internally consistent, and I traced the flag's set/clear (RAII via scopeguard::defer!), the three !dispatching && gates, and the new failure path's refcount handling — but the interaction surface (pending_requests accounting, can_pipeline, has_query_running, the advance_impl cleanup sweep, ReadyForQuery re-driving the loop) is broad enough that a maintainer who knows this subsystem should confirm nothing else relies on flush_data() or advance() acting immediately from inside an encoder.

Other factors

The test coverage is thorough and follows repo conventions (subprocess per scenario since failures abort/hang, describeWithContainer against postgres_plain, watchdog < test timeout so hangs report as structured JSON with stderr, concurrent stdout/stderr/exited drain, test.concurrent, exact-value assertions on every query result and on conversion count). The PR body reports the full sql.test.ts postgres block plus seven adjacent postgres regression suites pass. No prior human review on the timeline.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

On the one open question above (whether anything relies on flush_data() or advance() taking effect immediately while an encoder is on the stack), the call sites are:

  • advance(): the ReadyForQuery handler in on(), drain_internal(), advance_and_flush().
  • flush_data(): drain_internal(), advance_and_flush(), and the authentication handlers in on() (startup/password/SASL replies).

on() runs from on_data, and drain_internal() runs from the socket's writable callback or from the auto flusher's deferred task. Socket events are event-loop driven, so they cannot interleave with a synchronous encode. The two ways to reach these while IS_DISPATCHING is set are therefore a nested do_run calling advance_and_flush() (the bug itself) and the auto flusher running from a microtask checkpoint inside one of the reject callbacks advance() makes on an encode failure. In that second case the flush is delayed, not lost: on_auto_flush_impl keeps the flusher registered while the buffer is non-empty, and every caller of advance() flushes or re-registers the flusher after it returns (ReadyForQuery registers it, drain_internal and advance_and_flush call flush_data() directly, and do_run's fast path calls flush_data_and_reset_timeout() after its encode). Outside those two re-entrant paths the flag is never observed set, so the non-re-entrant behaviour is unchanged.

Comment thread src/sql/shared/ConnectionFlags.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresSQLConnection.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresSQLConnection.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresSQLConnection.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresSQLConnection.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresSQLQuery.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresSQLQuery.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresSQLQuery.rs Outdated
Comment thread src/sql/shared/ConnectionFlags.rs
Comment thread src/sql_jsc/postgres/PostgresSQLConnection.rs
Comment thread src/sql_jsc/postgres/PostgresSQLConnection.rs
Comment thread src/sql_jsc/postgres/PostgresSQLConnection.rs
Comment thread src/sql_jsc/postgres/PostgresSQLQuery.rs
Comment thread src/sql_jsc/postgres/PostgresSQLQuery.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and the automated bug hunt found no issues. Because it reworks the postgres dispatch state machine (queue-before-encode in the Prepared fast path, a new re-entrancy guard that gates advance()/flush_data(), and a new error-recovery path that dispatches nested queries), a human look is still warranted.

What was reviewed:

  • while_dispatching uses scopeguard::defer! so IS_DISPATCHING is cleared on every exit, including when bind_and_execute returns Err.
  • Refcount balance on the new encode-failure path: the speculative this.ref_() is released by discard_request when the request heads the queue, or by the later defer_cleanup! sweep when it doesn't (matching advance()'s own failure disposal).
  • dispatching gates all three write sites in do_run (simple, Prepared fast path, Parse/prepare path) and the !enqueued guard prevents double-enqueue.
  • The 9 subprocess scenarios drain both stdout and stderr concurrently, use describeWithContainer against a local postgres, and assert exact result shapes.
Extended reasoning...

Overview

Adds ConnectionFlags::IS_DISPATCHING, held by advance() and by do_run's enqueue-time Bind, so a query dispatched from user JS inside a parameter's valueOf()/toString() only enqueues rather than re-entering the encoder. flush_data() and advance() become no-ops while the flag is set. The Prepared fast path now enqueues before encoding and, on encode failure, pops the request via discard_failed_request (which then dispatches whatever was queued during the encode) and rethrows the taken exception. Ships a 9-scenario subprocess test against postgres_plain.

Security risks

None — this is internal dispatch ordering; no new parsing of untrusted input, no auth/crypto changes.

Level of scrutiny

High. This is the postgres wire-protocol dispatch state machine: queue ordering vs. wire ordering, pending_requests/pipelined_requests accounting, refcount release on a new error path, and a re-entrancy guard whose correctness depends on every holder draining and flushing afterwards. The PR description's argument for why deferred flushes are never lost is convincing but non-local (spans on(), drain_internal, advance_and_flush, the auto-flusher, and do_run's tail).

Other factors

  • Six unresolved comment-cop inline comments remain on the current head (3f82a0f); the author consolidated the explanation at while_dispatching but the linter still fires on the doc comments there and at discard_failed_request/advance(). Whether those are acceptable is a maintainer call.
  • The new error path in the Prepared fast path no longer calls release_statement() immediately (the old release_query_ref() did); the statement ref now lives until the query's Drop. It's also held by the connection's statements map so this isn't a leak, but it is a behavioural difference from the previous error path worth a second pair of eyes.
  • Test coverage is thorough (both encode sites, nested prepared/new/same statement, burst, second-level nesting, transaction, prepare: false, throw-after-dispatch) and asserts one conversion per query, which is the observable form of the fix.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

On the statement ref held across the new failure path: that is deliberate. release_query_ref() is the cleanup for a request that never made it onto the queue. Once a request is enqueued, nothing in this file releases its statement ref early: a request that completes, that the server rejects, or that advance() fails to encode all keep the ref until the PostgresSQLQuery is dropped (Drop is the only other caller of release_statement()). The fast path's request is now enqueued before it is encoded, so on failure it follows that same lifecycle. The connection's statements map holds its own ref on the statement anyway, so the difference is only when the query object's ref is released, not whether.

…oder

Encoding a Bind converts each parameter through user JS (valueOf, toString,
toJSON), which can synchronously dispatch another query on the same
connection. The nested run() either wrote its own frames into the middle of
the half-encoded Bind or re-entered advance(), which bound the still-Pending
head request a second time and then flushed the buffer out from under the
outer encoder, whose LengthWriter offsets pointed into it (panic in
Writer::pwrite; pending_requests underflow in debug builds).

Add ConnectionFlags::IS_DISPATCHING, held across advance() and across the
enqueue-time bind_and_execute in PostgresSQLQuery::do_run. While it is set,
do_run only enqueues, advance() returns immediately and flush_data() is a
no-op; the dispatching caller drains and flushes afterwards. The enqueue-time
Bind now puts its request on the queue before encoding, so requests
dispatched from inside the encode queue up behind it in the order their
replies will arrive; if that encode fails, the request is disposed of the way
advance() disposes of its failures and anything queued behind it is
dispatched before the error is rethrown.
@robobun
robobun force-pushed the farm/09aae61c/postgres-reentrant-advance branch from 3f82a0f to ef872de Compare August 14, 2026 02:43

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it reworks the postgres dispatch/encode ordering (enqueue-before-encode on the prepared fast path, a new connection-wide IS_DISPATCHING gate on advance()/flush_data(), and a new failure-path cleanup that re-enters advance() while an exception is held), a maintainer familiar with the connection state machine should sign off.

What was reviewed:

  • while_dispatching guard: cleared via scopeguard::defer!, callers only enter when !is_dispatching() (both advance() and do_run's fast path), so the debug_assert holds.
  • Failure path in do_run: request enqueued as Binding (counter = None, not counted in pending_requests), on encode error marked Fail then popped-if-head / swept-later — matches advance_impl's own encode-failure disposal; the statement ref now releases via Drop like every other enqueued request.
  • discard_failed_request → advance_and_flush(): exception is taken off the VM first; when the failed request was pipelined behind an in-flight one, IS_READY_FOR_QUERY is false so the drain is deferred to that request's ReadyForQuery — covered by the last test scenario.
  • flush_data() no-op inside the guard: every caller of advance() flushes or registers the auto-flusher after it returns, and flush_data_and_reset_timeout() inside advance_impl only registers (doesn't flush directly).
Extended reasoning...

Overview

Fixes a re-entrancy crash in the postgres client: a query parameter whose valueOf()/toString()/toJSON() synchronously dispatches another query on the same connection (any max: 1 pool or transaction) panics in Writer::pwrite or wedges the connection. The fix adds ConnectionFlags::IS_DISPATCHING, held for the duration of any Bind encode (while_dispatching), during which advance() and flush_data() early-return and do_run only enqueues. The prepared-statement fast path in do_run now enqueues the request before encoding it so nested dispatches queue behind it, with a new discard_failed_request cleanup on encode failure. Five files: three native (ConnectionFlags.rs, PostgresSQLConnection.rs, PostgresSQLQuery.rs) and two new test files (10 subprocess scenarios against the postgres_plain container).

Security risks

None. The change only affects internal write-buffer/queue ordering on a single JS thread; no new user-controlled input reaches a parser or allocation, and no auth/TLS/permission code is touched.

Level of scrutiny

High. This is native re-entrancy handling in the postgres wire-protocol dispatch loop, with intrusive-refcount lifetime implications. The change:

  • Reorders enqueue-before-encode on the fast path, which changes the failure-path cleanup contract (statement ref released via the query object's Drop rather than release_query_ref(); the ref_() on this is released via discard_request's deref instead of the local closure).
  • Adds a connection-wide flag that gates three hot-path functions.
  • Has discard_failed_request call advance_and_flush() (which can re-enter user JS via bind_and_execute) while the caller is holding a taken exception to rethrow.

I traced each of these and they hold up (see the bullets in the message), but this is exactly the shape of change where a maintainer who knows the connection state machine should confirm the invariants — particularly the interaction between the new Binding-before-encode state and finish_request/cleanup_and_fail_all_requests if the connection is torn down mid-encode.

Other factors

  • The bug-hunting system found nothing.
  • The test matrix is thorough (both encode sites × nested new/prepared/same/burst/recursive, transaction, prepare: false, throw-after-dispatch at head and behind an in-flight request), runs each in a subprocess, and the PR shows the expected 8/10-fail-without / 10/10-pass-with evidence on both debug+ASAN and release.
  • All comment-cop inline comments are resolved (comments consolidated at while_dispatching with pointers elsewhere).
  • No prior claude[bot] review on this PR.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

On the teardown-mid-encode interaction raised above, traced for the enqueue-time path: if the connection is closed synchronously from inside a conversion (only reachable through reserved.close() / a transaction's connection, since the pool's close() waits for the in-flight query), clean_up_requests now sees the request as Binding with counter == None, so finish_request is a no-op, on_js_error marks it Fail and returns early because target is only set in do_run's tail, and discard_request pops it. That part is balanced; advance()'s own encode window has the equivalent shape (its request is still Pending there). What is not handled, before or after this PR, is the rest of that scenario: close() frees the write buffer under the encoder, so both the old and the new code go on to panic in Writer::pwrite exactly as in the dispatch case (verified on the release binary and on this branch), and without that panic the encoder's caller would still carry on against the closed connection. That is a separate bug with a different trigger (close rather than dispatch) and is tracked separately; this PR leaves its outcome unchanged.

@robobun

robobun commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up for the fixture here: #41976 (the consolidated write_bind rework) stops consulting valueOf() when it binds a parameter. An object bound to an ::int4 parameter is no longer coerced with ToInt32. It goes in text format as String(value), which calls toString(). The scenarios in this PR re-enter from valueOf() on ::int4 parameters, so after #41976 lands they need a toString() hook (or a [Symbol.toPrimitive]) to keep running inside the Bind encoder.

alii pushed a commit that referenced this pull request Sep 24, 2026
#34732)

### Problem
- `write_bind` (`src/sql_jsc/postgres/PostgresRequest.rs:52`) writes
into `connection.write_buffer` and calls JS per parameter. When a
parameter fails (a throwing `toString`, since #41889 a bad `bytea`
value), the query rejects but the partial Bind stays: `42 00 00 00 00`
(length 0).
- The next flush sends it, even with nothing queued: `P D S B(len=0)`,
or `P D B(len=0)` with `prepare: false`. PostgreSQL drops the
connection. The next query, pipelined siblings and open transactions get
`ERR_POSTGRES_CONNECTION_CLOSED`.

### Fix
- `NewWriter::atomically` records `offset()`, runs the body, and
truncates back on failure. It wraps the three batch writers that reach
`write_bind`: a rejected query writes nothing.
- It truncates only when `write_epoch` is unchanged. The connection
bumps it for every `Writer` it hands out and every drain or free of
`write_buffer`. A conversion that starts a query with `.execute()`
changes it, and the connection then fails as on main.
- `Writer::pwrite` now adds `head` (the bytes already sent) to the
index, like `offset()`.
- Verified: `postgres-bind-encode-throw.test.ts`,
`postgres-bytea-bind.test.ts` (unfixed main fails 4/5 and 6/12). Other
suites: Notes.

### Background
- `prepare: false` writes Parse, Describe, Bind, Execute, Flush, Sync as
one batch, so the rollback drops them too.
- This is the interim fix for 1.4.3. The follow-up converts every
parameter before the first write, so no rollback is needed.

### Downsides
- Success path, per batch: two reads and one branch. Per `Writer`: one
increment. Per length patch: one add.
- A conversion that dispatches a query and then fails still loses the
connection, as on main. Without the failure both hang (#38231). The
follow-up fixes both.

<details><summary>Notes</summary>

**Wire captures on unfixed main (367d939), byte-capturing mock
server.** Each line is the list of frontend messages after the startup
packet.

- Lone rejected query, named statement: `P D S B(len=0)`. The bytes: `42
00 00 00 00 00 50 73 65 6c 65 63 74 ...` (Bind, length 0, empty portal
name, then the statement name).
- Lone rejected query, `prepare: false`: `P D B(len=0)`. The bytes: `42
00 00 00 00 00 00 00 02 00 00 00 00 00 02 00 00 00 01 61`. The Parse and
Describe have no Sync behind them.
- Nothing is queued behind the rejected query in both captures. The
auto-flusher sends the buffer at the end of the tick.
- With the fix: the named case sends `P D S` and no Bind. The `prepare:
false` case sends nothing for the rejected query.

**Paths covered by the tests.**

- `bind_and_execute` from `advance` (the Bind follows the statement's
Parse/Describe round trip): both files, real server and mock.
- `bind_and_execute` from `run` (statement already prepared, Bind
written at query time): `postgres-bytea-bind.test.ts` runs each rejected
query twice. The second run takes this path.
- `parse_and_bind_and_execute` (`prepare: false`): real server with a
queued sibling, real server with a lone query and a backend pid check,
and the mock that asserts the exact message list.
- `prepare_and_query_with_signature` has no parameters, so no parameter
can throw there. It is wrapped for the `TooManyParameters` path.
- The bytea case (#41889): a `number[]` bound to `bytea` rejects with
`ERR_INVALID_ARG_TYPE`. On unfixed main the next query, the pipelined
siblings and the open transaction then fail with
`ERR_POSTGRES_CONNECTION_CLOSED`.

**The mock server** in `postgres-bind-encode-throw.test.ts` drops the
connection when it reads a length below 4, as PostgreSQL does. On
unfixed main the mock tests fail at once with `["B(len=0)"]`. They do
not wait for a timeout.

**Nested dispatch from inside a conversion (found in review, confirmed
on a real PostgreSQL).** `query.execute()` calls into the connection
synchronously (`await`/`.then()` defer by one microtask and are not
affected). When a parameter's `toString` creates a query for a prepared
statement on the same connection and calls `.execute()` on it, the
nested query writes its Bind/Execute/Sync inside the outer query's open
Bind.

- Conversion does not throw, main and this PR: the outer query never
settles, the nested one rejects with `08P01 insufficient data left in
message`, and later queries on the connection hang. #38231 is the open
fix.
- Conversion throws after the nested dispatch, main and this PR:
`B(len=0)` goes out, the server drops the connection, the nested query
rejects with `ERR_POSTGRES_CONNECTION_CLOSED`, the pool reconnects and
the next query resolves.
- An earlier head of this PR (9812be4) rolled back without the
`write_epoch` check. It removed the nested query's frames too: the
nested query hung, or a later query's row resolved it (`nested: resolved
[{"nested":"LATER-VALUE"}]`). The test `a query dispatched from inside a
conversion that then fails never gets another query's row` times out on
that head and passes now.
- Checked 5 cells against main on a real server (outer statement already
prepared or on its first execution, nested query prepared, new, simple,
or `unsafe` with parameters): the outcomes are the same as on main in
every cell. A sixth cell (first execution, nested new statement) aborts
the debug build with `panic: pending_requests underflow`, a debug
assertion that this diff does not touch. The release build of main has
no panic in that cell.

**`head` in `pwrite` and `truncate`.** `offset()` is relative to `head`,
so both now add `head`. No test reaches `head != 0` during a write, and
the public API does not reach it without a flush from inside a
conversion. `OffsetByteList::consume` leaves `head > 0` only when a
socket write accepted less than half of the pending bytes. That write
sets `HAS_BACKPRESSURE`. Under backpressure `advance()` does not run its
loop, and every write gate in `do_run` needs `!has_query_running()` or
`can_pipeline()`, which are both false while a request with pending
bytes is in the queue. A flush from inside a conversion bumps
`write_epoch`, so no rollback follows it.

**Follow-up.** A design review of six candidates picked a different
long-term shape: convert every parameter before the first byte of the
batch is written (no user code runs while a frame is open), plus a guard
so that a query dispatched during a conversion only enqueues. It is
planned as three PRs. This PR is the interim fix. See
#34732 (comment).

**Suites run with the debug build:** the
`test/js/sql/postgres-*.test.ts` files, `sql-prepare-false.test.ts`,
`sql-pool-transaction-isolation.test.ts`, `wire-frames.test.ts` and
`sql.test.ts` (29 files): 236 of 236 tests pass, with
`postgres-string-leak.test.ts` run apart. The two tests in
`postgres-string-leak.test.ts` time out at the default 5 s in this
environment: the fixture alone takes 8.6 s under the debug ASAN build
here (0.46 s under release). Its RSS delta is 9.65 MiB, inside the
test's 80 MiB bound.

**History.** The July version of this PR had the same design. It was
refreshed on current main after the `ArrayList` writer context was
removed. The test file `postgres-bind-throw-torn-frame.test.ts` was
replaced by `postgres-bind-encode-throw.test.ts`. The head now also
carries the `postgres-bytea-bind.test.ts` additions from the branch
`robobun/44cd9150/postgres-bind-rollback`.

MySQL is not affected. `bind_and_execute_impl` converts every parameter
to a native `Vec<Value>` before it touches the writer.
</details>

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 2 · 4 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/sql/postgres-bind-encode-throw.test.ts
bun test v1.4.3 (f42e980)

test/js/sql/postgres-bind-encode-throw.test.ts:
Container ready via docker-compose: postgres_plain at 127.0.0.1:5432
204 |   return `{${values.map(arrayValueSerializer.bind(this, type, isPostgresNumericType(type), isPostgresJsonType(type))).join(delimiter)}}`;
205 | }
206 | function wrapPostgresError(error) {
207 |   if (Error.isError(error)) {
208 |     return error;
209 |   return new PostgresError(error.message, error);
               ^
PostgresError: Connection closed
 code: "ERR_POSTGRES_CONNECTION_CLOSED"

      at wrapPostgresError (internal:sql/postgres:209:10)
      at handleClose (internal:sql/shared:463:27)
(fail) postgres > a parameter that throws while encoded does not break the queries pipelined behind it [308.18ms]
204 |   return `{${values.map(arrayValueSerializer.bind(this, type, isPostgresNumericType(type), isPostgresJsonType(type))).join(delimiter)}}`;
205 | }
206 | function wrapPostgresError(error) {
207 |   if (Error.isError(error)) {
208 | 
... (truncated)

release without fix: 3 FAILED
bun test v1.4.3-canary.1 (f42e980)

test/js/sql/postgres-bind-encode-throw.test.ts:
Container ready via docker-compose: postgres_plain at 127.0.0.1:5432
171 |   let delimiter = type === "BOX" ? ";" : ",";
172 |   return `{${values.map(arrayValueSerializer.bind(this, type, isPostgresNumericType(type), isPostgresJsonType(type))).join(delimiter)}}`;
173 | }
174 | function wrapPostgresError(error) {
175 |   if (Error.isError(error))
176 |   return new PostgresError(error.message, error);
               ^
PostgresError: Connection closed
 code: "ERR_POSTGRES_CONNECTION_CLOSED"

      at wrapPostgresError (internal:sql/postgres:176:10)
      at handleClose (internal:sql/shared:372:27)
(fail) postgres > a parameter that throws while encoded does not break the queries pipelined behind it [13.17ms]
171 |   let delimiter = type === "BOX" ? ";" : ",";
172 |   return `{${values.map(arrayValueSerializer.bind(this, type, isPostgresNumericType(type), isPostgresJsonType(type))).join(delimiter)}}`;
173 | }
174 | function wrapPostgresError(error) {
175 |   if (Error.isError(error))
176 |   return new PostgresError(error.message, error);
               ^
PostgresError: Connection cl
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/sql/postgres-bind-encode-throw.test.ts
bun test v1.4.3 (f42e980)

test/js/sql/postgres-bind-encode-throw.test.ts:
Container ready via docker-compose: postgres_plain at 127.0.0.1:5432
(pass) postgres > a parameter that throws while encoded does not break the queries pipelined behind it [297.21ms]
(pass) postgres > a throwing parameter on the first execution of a statement does not break the next query [38.94ms]
(pass) postgres bind encode failure (mock server) > no partial Bind reaches the wire when a parameter throws [368.75ms]

 3 pass
 0 fail
 6 expect() calls
Ran 3 tests across 1 file. [3.42s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 771ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/38] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 242 extern-C blocks audited
[2/38] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
  - Glob (5 fields)
Found 1 classes from /workspace/bun/src/runtime/api/h2.classes.ts
  - H2FrameParser (32 fields)
Found 9 
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/sql/postgres/protocol/NewWriter.rs         |  15 ++
 src/sql_jsc/postgres/PostgresRequest.rs        | 192 +++++++++++++------------
 src/sql_jsc/postgres/PostgresSQLConnection.rs  |  12 ++
 test/js/sql/postgres-bind-encode-throw.test.ts | 182 +++++++++++++++++++++++
 4 files changed, 308 insertions(+), 93 deletions(-)
```

</details>

**gate history** · 2 passed · 0 rejected · iteration 2

<details><summary>evidence per changed file</summary>

```
file                                            reads  edits  tests
src/sql/postgres/protocol/NewWriter.rs              2      4      0
src/sql_jsc/postgres/PostgresRequest.rs             2      3      1
src/sql_jsc/postgres/PostgresSQLConnection.rs       2      1      0
test/js/sql/postgres-bind-encode-throw.test.ts      0      0      0
```

</details>

<!-- robobun:evidence:end -->
@robobun

robobun commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favour of #43918. It carries this fix on current main, stacked on #43892, and its tests include the ten scenarios of this PR. It uses a field of the postgres connection, because the flag bit 1 << 5 of this PR is KEEP_ALIVE_REQUESTED on main. The branch stays, so this PR can be reopened if #43918 does not land.

@robobun robobun closed this Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant